a tool for shared writing and social publishing
at update/reader 487 lines 17 kB view raw
1"use client"; 2import { publishToPublication } from "actions/publishToPublication"; 3import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4import { ActionButton } from "components/ActionBar/ActionButton"; 5import { 6 PubIcon, 7 PubListEmptyContent, 8 PubListEmptyIllo, 9} from "components/ActionBar/Publications"; 10import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; 11import { AddSmall } from "components/Icons/AddSmall"; 12import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 13import { PublishSmall } from "components/Icons/PublishSmall"; 14import { useIdentityData } from "components/IdentityProvider"; 15import { InputWithLabel } from "components/Input"; 16import { Menu, MenuItem } from "components/Menu"; 17import { 18 useLeafletDomains, 19 useLeafletPublicationData, 20} from "components/PageSWRDataProvider"; 21import { Popover } from "components/Popover"; 22import { SpeedyLink } from "components/SpeedyLink"; 23import { useToaster } from "components/Toast"; 24import { DotLoader } from "components/utils/DotLoader"; 25import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 26import { useParams, useRouter, useSearchParams } from "next/navigation"; 27import { useState, useMemo, useEffect } from "react"; 28import { useIsMobile } from "src/hooks/isMobile"; 29import { useReplicache, useEntity } from "src/replicache"; 30import { useSubscribe } from "src/replicache/useSubscribe"; 31import { Json } from "supabase/database.types"; 32import { 33 useBlocks, 34 useCanvasBlocksWithType, 35} from "src/hooks/queries/useBlocks"; 36import * as Y from "yjs"; 37import * as base64 from "base64-js"; 38import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 39import { BlueskyLogin } from "app/login/LoginForm"; 40import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 41import { AddTiny } from "components/Icons/AddTiny"; 42import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 43import { useLocalPublishedAt } from "components/Pages/Backdater"; 44 45export const PublishButton = (props: { entityID: string }) => { 46 let { data: pub } = useLeafletPublicationData(); 47 let params = useParams(); 48 let router = useRouter(); 49 50 if (!pub) return <PublishToPublicationButton entityID={props.entityID} />; 51 if (!pub?.doc) 52 return ( 53 <ActionButton 54 primary 55 icon={<PublishSmall className="shrink-0" />} 56 label={"Publish!"} 57 onClick={() => { 58 router.push(`/${params.leaflet_id}/publish`); 59 }} 60 /> 61 ); 62 63 return <UpdateButton />; 64}; 65 66const UpdateButton = () => { 67 let [isLoading, setIsLoading] = useState(false); 68 let { data: pub, mutate } = useLeafletPublicationData(); 69 let { permission_token, rootEntity, rep } = useReplicache(); 70 let { identity } = useIdentityData(); 71 let toaster = useToaster(); 72 73 // Get title and description from Replicache state (same as draft editor) 74 // This ensures we use the latest edited values, not stale cached data 75 let replicacheTitle = useSubscribe(rep, (tx) => 76 tx.get<string>("publication_title"), 77 ); 78 let replicacheDescription = useSubscribe(rep, (tx) => 79 tx.get<string>("publication_description"), 80 ); 81 82 // Use Replicache state if available, otherwise fall back to pub data 83 const currentTitle = 84 typeof replicacheTitle === "string" ? replicacheTitle : pub?.title || ""; 85 const currentDescription = 86 typeof replicacheDescription === "string" 87 ? replicacheDescription 88 : pub?.description || ""; 89 90 // Get tags from Replicache state (same as draft editor) 91 let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags")); 92 const currentTags = Array.isArray(tags) ? tags : []; 93 94 // Get cover image from Replicache state 95 let coverImage = useSubscribe(rep, (tx) => 96 tx.get<string | null>("publication_cover_image"), 97 ); 98 99 // Get post preferences from Replicache state 100 let postPreferences = useSubscribe(rep, (tx) => 101 tx.get<{ 102 showComments?: boolean; 103 showMentions?: boolean; 104 showRecommends?: boolean; 105 } | null>("post_preferences"), 106 ); 107 108 // Get local published at from Replicache (session-only state, not persisted to DB) 109 let publishedAt = useLocalPublishedAt((s) => 110 pub?.doc ? s[pub?.doc] : undefined, 111 ); 112 113 return ( 114 <ActionButton 115 primary 116 icon={<PublishSmall className="shrink-0" />} 117 label={isLoading ? <DotLoader /> : "Update!"} 118 onClick={async () => { 119 if (!pub) return; 120 setIsLoading(true); 121 let result = await publishToPublication({ 122 root_entity: rootEntity, 123 publication_uri: pub.publications?.uri, 124 leaflet_id: permission_token.id, 125 title: currentTitle, 126 description: currentDescription, 127 tags: currentTags, 128 cover_image: coverImage, 129 publishedAt: publishedAt?.toISOString(), 130 postPreferences, 131 }); 132 setIsLoading(false); 133 mutate(); 134 135 if (!result.success) { 136 toaster({ 137 content: isOAuthSessionError(result.error) ? ( 138 <OAuthErrorMessage error={result.error} /> 139 ) : ( 140 "Failed to publish" 141 ), 142 type: "error", 143 }); 144 return; 145 } 146 147 // Generate URL based on whether it's in a publication or standalone 148 let docUrl = pub.publications 149 ? `${getPublicationURL(pub.publications)}/${result.rkey}` 150 : `https://leaflet.pub/p/${identity?.atp_did}/${result.rkey}`; 151 152 toaster({ 153 content: ( 154 <div className="font-bold"> 155 {pub.doc ? "Updated! " : "Published! "} 156 <SpeedyLink className="underline" href={docUrl}> 157 See Published Post 158 </SpeedyLink> 159 </div> 160 ), 161 type: "success", 162 }); 163 }} 164 /> 165 ); 166}; 167 168const PublishToPublicationButton = (props: { entityID: string }) => { 169 let { identity } = useIdentityData(); 170 let { permission_token } = useReplicache(); 171 let query = useSearchParams(); 172 let [open, setOpen] = useState(query.get("publish") !== null); 173 174 let isMobile = useIsMobile(); 175 identity && identity.atp_did && identity.publications.length > 0; 176 let [selectedPub, setSelectedPub] = useState<string | undefined>(undefined); 177 let router = useRouter(); 178 let { title, entitiesToDelete } = useTitle(props.entityID); 179 let [description, setDescription] = useState(""); 180 181 return ( 182 <Popover 183 asChild 184 open={open} 185 onOpenChange={(o) => setOpen(o)} 186 side={isMobile ? "top" : "right"} 187 align={isMobile ? "center" : "start"} 188 className="sm:max-w-sm w-[1000px]" 189 trigger={ 190 <ActionButton 191 primary 192 icon={<PublishSmall className="shrink-0" />} 193 label={"Publish on ATP"} 194 /> 195 } 196 > 197 {!identity || !identity.atp_did ? ( 198 <div className="-mx-2 -my-1"> 199 <div 200 className={`bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm`} 201 > 202 <div className="mx-auto pt-2 scale-90"> 203 <PubListEmptyIllo /> 204 </div> 205 <div className="pt-1 font-bold">Publish on AT Proto</div> 206 { 207 <> 208 <div className="pb-2 text-secondary text-xs"> 209 Link a Bluesky account to start <br /> a publishing on AT 210 Proto 211 </div> 212 213 <BlueskyLogin 214 compact 215 redirectRoute={`/${permission_token.id}?publish`} 216 /> 217 </> 218 } 219 </div> 220 </div> 221 ) : ( 222 <div className="flex flex-col"> 223 <PostDetailsForm 224 title={title} 225 description={description} 226 setDescription={setDescription} 227 /> 228 <hr className="border-border-light my-3" /> 229 <div> 230 <PubSelector 231 publications={identity.publications} 232 selectedPub={selectedPub} 233 setSelectedPub={setSelectedPub} 234 /> 235 </div> 236 <hr className="border-border-light mt-3 mb-2" /> 237 238 <div className="flex gap-2 items-center place-self-end"> 239 {selectedPub !== "looseleaf" && selectedPub && ( 240 <SaveAsDraftButton 241 selectedPub={selectedPub} 242 leafletId={permission_token.id} 243 metadata={{ title: title, description }} 244 entitiesToDelete={entitiesToDelete} 245 /> 246 )} 247 <ButtonPrimary 248 disabled={selectedPub === undefined} 249 onClick={async (e) => { 250 if (!selectedPub) return; 251 e.preventDefault(); 252 if (selectedPub === "create") return; 253 254 // For looseleaf, navigate without publication_uri 255 if (selectedPub === "looseleaf") { 256 router.push( 257 `${permission_token.id}/publish?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`, 258 ); 259 } else { 260 router.push( 261 `${permission_token.id}/publish?publication_uri=${encodeURIComponent(selectedPub)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&entitiesToDelete=${encodeURIComponent(JSON.stringify(entitiesToDelete))}`, 262 ); 263 } 264 }} 265 > 266 Next{selectedPub === "create" && ": Create Pub!"} 267 </ButtonPrimary> 268 </div> 269 </div> 270 )} 271 </Popover> 272 ); 273}; 274 275const SaveAsDraftButton = (props: { 276 selectedPub: string | undefined; 277 leafletId: string; 278 metadata: { title: string; description: string }; 279 entitiesToDelete: string[]; 280}) => { 281 let { mutate } = useLeafletPublicationData(); 282 let { rep } = useReplicache(); 283 let [isLoading, setIsLoading] = useState(false); 284 285 return ( 286 <ButtonTertiary 287 onClick={async (e) => { 288 if (!props.selectedPub) return; 289 if (props.selectedPub === "create") return; 290 e.preventDefault(); 291 setIsLoading(true); 292 await moveLeafletToPublication( 293 props.leafletId, 294 props.selectedPub, 295 props.metadata, 296 props.entitiesToDelete, 297 ); 298 await Promise.all([rep?.pull(), mutate()]); 299 setIsLoading(false); 300 }} 301 > 302 {isLoading ? <DotLoader /> : "Save as Draft"} 303 </ButtonTertiary> 304 ); 305}; 306 307const PostDetailsForm = (props: { 308 title: string; 309 description: string; 310 setDescription: (d: string) => void; 311}) => { 312 return ( 313 <div className=" flex flex-col gap-1"> 314 <div className="text-sm text-tertiary">Post Details</div> 315 <div className="flex flex-col gap-2"> 316 <InputWithLabel label="Title" value={props.title} disabled /> 317 <InputWithLabel 318 label="Description (optional)" 319 textarea 320 value={props.description} 321 className="h-[4lh]" 322 onChange={(e) => props.setDescription(e.currentTarget.value)} 323 /> 324 </div> 325 </div> 326 ); 327}; 328 329const PubSelector = (props: { 330 selectedPub: string | undefined; 331 setSelectedPub: (s: string) => void; 332 publications: { 333 identity_did: string; 334 indexed_at: string; 335 name: string; 336 record: Json | null; 337 uri: string; 338 }[]; 339}) => { 340 // HEY STILL TO DO 341 // test out logged out, logged in but no pubs, and pubbed up flows 342 343 return ( 344 <div className="flex flex-col gap-1"> 345 <div className="text-sm text-tertiary">Publish to</div> 346 {props.publications.length === 0 || props.publications === undefined ? ( 347 <div className="flex flex-col gap-1"> 348 <div className="flex gap-2 menuItem"> 349 <LooseLeafSmall className="shrink-0" /> 350 <div className="flex flex-col leading-snug"> 351 <div className="text-secondary font-bold"> 352 Publish as Looseleaf 353 </div> 354 <div className="text-tertiary text-sm font-normal"> 355 Publish this as a one off doc to AT Proto 356 </div> 357 </div> 358 </div> 359 <div className="flex gap-2 px-2 py-1 "> 360 <PublishSmall className="shrink-0 text-border" /> 361 <div className="flex flex-col leading-snug"> 362 <div className="text-border font-bold"> 363 Publish to Publication 364 </div> 365 <div className="text-border text-sm font-normal"> 366 Publish your writing to a blog on AT Proto 367 </div> 368 <hr className="my-2 drashed border-border-light border-dashed" /> 369 <div className="text-tertiary text-sm font-normal "> 370 You don't have any Publications yet.{" "} 371 <a target="_blank" href="/lish/createPub"> 372 Create one 373 </a>{" "} 374 to get started! 375 </div> 376 </div> 377 </div> 378 </div> 379 ) : ( 380 <div className="flex flex-col gap-1"> 381 <PubOption 382 selected={props.selectedPub === "looseleaf"} 383 onSelect={() => props.setSelectedPub("looseleaf")} 384 > 385 <LooseLeafSmall /> 386 Publish as Looseleaf 387 </PubOption> 388 <hr className="border-border-light border-dashed " /> 389 {props.publications.map((p) => { 390 let pubRecord = normalizePublicationRecord(p.record); 391 return ( 392 <PubOption 393 key={p.uri} 394 selected={props.selectedPub === p.uri} 395 onSelect={() => props.setSelectedPub(p.uri)} 396 > 397 <> 398 <PubIcon record={pubRecord} uri={p.uri} /> 399 {p.name} 400 </> 401 </PubOption> 402 ); 403 })} 404 <div className="flex items-center px-2 py-1 text-accent-contrast gap-2"> 405 <AddTiny className="m-1 shrink-0" /> 406 407 <a target="_blank" href="/lish/createPub"> 408 Start a new Publication 409 </a> 410 </div> 411 </div> 412 )} 413 </div> 414 ); 415}; 416 417const PubOption = (props: { 418 selected: boolean; 419 onSelect: () => void; 420 children: React.ReactNode; 421}) => { 422 return ( 423 <button 424 className={`flex gap-2 menuItem font-bold text-secondary ${props.selected && "bg-[var(--accent-light)]! outline! outline-offset-1! outline-accent-contrast!"}`} 425 onClick={() => { 426 props.onSelect(); 427 }} 428 > 429 {props.children} 430 </button> 431 ); 432}; 433 434let useTitle = (entityID: string) => { 435 let rootPage = useEntity(entityID, "root/page")[0].data.value; 436 let canvasBlocks = useCanvasBlocksWithType(rootPage).filter( 437 (b) => b.type === "text" || b.type === "heading", 438 ); 439 let blocks = useBlocks(rootPage).filter( 440 (b) => b.type === "text" || b.type === "heading", 441 ); 442 let firstBlock = canvasBlocks[0] || blocks[0]; 443 444 let firstBlockText = useEntity(firstBlock?.value, "block/text")?.data.value; 445 446 const leafletTitle = useMemo(() => { 447 if (!firstBlockText) return "Untitled"; 448 let doc = new Y.Doc(); 449 const update = base64.toByteArray(firstBlockText); 450 Y.applyUpdate(doc, update); 451 let nodes = doc.getXmlElement("prosemirror").toArray(); 452 return YJSFragmentToString(nodes[0]) || "Untitled"; 453 }, [firstBlockText]); 454 455 // Only handle second block logic for linear documents, not canvas 456 let isCanvas = canvasBlocks.length > 0; 457 let secondBlock = !isCanvas ? blocks[1] : undefined; 458 let secondBlockTextValue = useEntity(secondBlock?.value || null, "block/text") 459 ?.data.value; 460 const secondBlockText = useMemo(() => { 461 if (!secondBlockTextValue) return ""; 462 let doc = new Y.Doc(); 463 const update = base64.toByteArray(secondBlockTextValue); 464 Y.applyUpdate(doc, update); 465 let nodes = doc.getXmlElement("prosemirror").toArray(); 466 return YJSFragmentToString(nodes[0]) || ""; 467 }, [secondBlockTextValue]); 468 469 let entitiesToDelete = useMemo(() => { 470 let etod: string[] = []; 471 // Only delete first block if it's a heading type 472 if (firstBlock?.type === "heading") { 473 etod.push(firstBlock.value); 474 } 475 // Delete second block if it's empty text (only for linear documents) 476 if ( 477 !isCanvas && 478 secondBlockText.trim() === "" && 479 secondBlock?.type === "text" 480 ) { 481 etod.push(secondBlock.value); 482 } 483 return etod; 484 }, [firstBlock, secondBlockText, secondBlock, isCanvas]); 485 486 return { title: leafletTitle, entitiesToDelete }; 487};